Repository: Textualize/textual
Pull request: Add `OptionList` widget
Adds an `OptionList` widget, which can form the basis of traditional bounce-bar menus, and also can provide a low-cost item list, etc.

Initially started to implement the requirement outlined in #2053 but turned into something more foundational.

Key features include:

- Just a single widget, so ideally allowing *many* options within the list with little cost to the application.
- Each option's prompt can be any sort of Rich renderable, with multi-line renderables supported.
- Messages for highlight and selection.
- Can be built up from a list of strings, or `Option` classes.
- Provides support for non-option content -- currently supporting a separator.

<img width="1258" alt="Screenshot 2023-03-29 at 09 36 20" src="https://user-images.githubusercontent.com/28237/228476766-32e79302-3080-4c25-afbd-c56b859b266a.png">

<img width="826" alt="Screenshot 2023-03-29 at 09 52 40" src="https://user-images.githubusercontent.com/28237/228480942-2cc66289-69cf-4fc7-9df3-89fdada7fcc1.png">

<img width="826" alt="Screenshot 2023-03-29 at 09 52 51" src="https://user-images.githubusercontent.com/28237/228480974-e39dfd59-1e5f-4173-9adb-595681a2b496.png">

<img width="826" alt="Screenshot 2023-03-29 at 09 53 03" src="https://user-images.githubusercontent.com/28237/228480997-24a84733-09e9-43fb-a0d1-48c5a6e4c057.png">


Problem statement:
Menu Line API widget
We need a `Menu` widget which displays a list of items that may be navigated and selected.

Similar to ListView but using the Line API.

The menu should accept a list of renderables (not just str and Text), which will be presented in a vertically stacked list. It should display a cursor that may be moved up and down.

- We would need to identify the menu items with an optional id. Perhaps also by index.
- Renderables may be an arbitrary size. The `add_item` method should accept a fixed height, but a height of None should render the item with an automatic height.
- Menu items should allow an automatic checkbox control which updates a set on the parent.

Hints from issue discussion:

Commit message: Tweak the OptionList hover tests some more

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dae30d7d3d..c6ac7ca1bc 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 0000000000..6c7bc05b53
--- /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 0000000000..cc1b7365f5
--- /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 0000000000..de9157c1cf
--- /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 0000000000..d170efa571
--- /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 0000000000..aaf51a0a24
--- /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 abcd8a7492..7a9d40e290 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 0000000000..2d23768a82
--- /dev/null
+++ b/docs/widgets/option_list.md
@@ -0,0 +1,84 @@
+# OptionList
+
+!!! tip "Added in version 0.17.0"
+
+A widget for showing a vertical list of Rich renderable options.
+
+- [x] Focusable
+- [ ] Container
+
+## Examples
+
+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"
+    ~~~
+
+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"
+    ~~~
+
+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. |
+
+## See Also
+
+* [OptionList][textual.widgets.OptionList] code reference
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index e785346eb4..60b69e2287 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/widgets/__init__.py b/src/textual/widgets/__init__.py
index db57172848..003a926425 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -23,6 +23,7 @@
     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 @@
     "LoadingIndicator",
     "Markdown",
     "MarkdownViewer",
+    "OptionList",
     "Placeholder",
     "Pretty",
     "RadioButton",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 66e78fe941..3bde066196 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 0000000000..494d4b85d1
--- /dev/null
+++ b/src/textual/widgets/_option_list.py
@@ -0,0 +1,904 @@
+"""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, Iterator, NamedTuple
+
+from rich.console import RenderableType
+from rich.repr import Result
+from rich.rule import Rule
+from rich.segment import Segment
+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 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: An 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:
+        """An 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 that denotes that a particular option is really a separator."""
+
+
+class Line(NamedTuple):
+    """Class that holds a list of segments for the line of a option."""
+
+    segments: list[Segment]
+    """The list 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 represnets 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", "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", "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: $panel-lighten-2;
+    }
+
+    OptionList > .option-list--option-hover-disabled {
+        color: $text-disabled;
+        background: $panel-lighten-2;
+    }
+
+    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 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, old: bool, new: bool) -> None:
+        """Handle the vertical scrollbar visibility status changing.
+
+        Args:
+            old: The old status.
+            new: The new status.
+
+        `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.
+        """
+        if old != new:
+            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.
+
+        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 = 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(prompt_line, 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.
+        """
+        # 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 open with the given ID.
+
+        Args:
+            option_id: The ID of the option to remove.
+
+        Returns:
+            The `OptionList` instance.
+
+        Raises:
+            KeyError: If no option has the given ID.
+        """
+        self._remove_option(self._option_ids[option_id])
+        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:
+            IndexError: If there is no option with the given index.
+        """
+        self._remove_option(index)
+        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:
+            IndexError: If there is no option with the given index.
+        """
+        return self._set_option_disabled(index, False)
+
+    def disable_option_at_index(self, index: int) -> Self:
+        """Disable the option at the given index.
+
+        Returns:
+            The `OptionList` instance.
+
+        Raises:
+            IndexError: If there is no option with the given index.
+        """
+        return self._set_option_disabled(index, True)
+
+    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:
+            KeyError: If no option has the given ID.
+        """
+        return self.enable_option_at_index(self._option_ids[option_id])
+
+    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:
+            KeyError: If no option has the given ID.
+        """
+        return self.disable_option_at_index(self._option_ids[option_id])
+
+    @property
+    def option_count(self) -> int:
+        """The count of options."""
+        return len(self._options)
+
+    @property
+    def options(self) -> Iterator[Option]:
+        """An iterator of options within the list."""
+        return iter(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:
+            IndexError: If there is no option with the index.
+        """
+        return self._options[index]
+
+    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:
+            KeyError: If no option has the given ID.
+        """
+        return self.get_option_at_index(self._option_ids[option_id])
+
+    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.
+        # While we're here, let's also attach which option index the line is
+        # with.
+        strip = Strip(line.segments).apply_style(Style(meta={"option": option_index}))
+
+        # 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=self.show_vertical_scrollbar,  # https://github.com/Textualize/textual/issues/2077
+        )
+
+    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_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_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 a 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 0000000000..d35b0d6976
--- /dev/null
+++ b/src/textual/widgets/option_list.py
@@ -0,0 +1,3 @@
+from ._option_list import DuplicateID, Option, Separator
+
+__all__ = ["DuplicateID", "Option", "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 0000000000..9be93c4869
--- /dev/null
+++ b/tests/option_list/test_option_list_create.py
@@ -0,0 +1,110 @@
+"""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, 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(KeyError):
+            _ = 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(IndexError):
+            _ = pilot.app.query_one(OptionList).get_option_at_index(42)
+        with pytest.raises(IndexError):
+            _ = 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 0000000000..dcfa54f281
--- /dev/null
+++ b/tests/option_list/test_option_list_disabled.py
@@ -0,0 +1,78 @@
+"""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:
+        for option in pilot.app.query_one(OptionList).options:
+            assert option.disabled is False
+
+
+async def test_default_disabled() -> None:
+    """Options created disabled should remain disabled."""
+    async with OptionListApp(True).run_test() as pilot:
+        for option in pilot.app.query_one(OptionList).options:
+            assert 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 0000000000..a76f782f81
--- /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 0000000000..65e425beeb
--- /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 0000000000..9e59c55ddb
--- /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 0000000000..67f190550a
--- /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 0000000000..45d2748d89
--- /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 42c7fd18a1..7cb6aca01a 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -17263,6 +17263,493 @@
   
   '''
 # ---
+# name: test_option_list
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1812315577-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1812315577-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1812315577-r1 { fill: #c5c8c6 }
+  .terminal-1812315577-r2 { fill: #e3e3e3 }
+  .terminal-1812315577-r3 { fill: #e1e1e1 }
+  .terminal-1812315577-r4 { fill: #004578 }
+  .terminal-1812315577-r5 { fill: #e0e8ee;font-weight: bold }
+  .terminal-1812315577-r6 { fill: #e2e3e3 }
+  .terminal-1812315577-r7 { fill: #ddedf9 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1812315577-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1812315577-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1812315577-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1812315577-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">OptionListApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1812315577-clip-terminal)">
+      <rect fill="#282828" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="1.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="85.4" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="97.6" y="1.5" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="390.4" y="1.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="549" y="1.5" width="305" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="99.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="123.5" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="244" y="123.5" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="147.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="147.9" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="172.3" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="172.3" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="196.7" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="196.7" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="221.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="221.1" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="245.5" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="245.5" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="269.9" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="269.9" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="294.3" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="219.6" y="294.3" width="597.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="318.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="280.6" y="318.7" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="343.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="343.1" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="367.5" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="367.5" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="391.9" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="391.9" width="585.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="416.3" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="440.7" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="465.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1812315577-matrix">
+      <text class="terminal-1812315577-r2" x="12.2" y="20" textLength="12.2" clip-path="url(#terminal-1812315577-line-0)">⭘</text><text class="terminal-1812315577-r2" x="390.4" y="20" textLength="158.6" clip-path="url(#terminal-1812315577-line-0)">OptionListApp</text><text class="terminal-1812315577-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1812315577-line-0)">
+  </text><text class="terminal-1812315577-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-1)">
+  </text><text class="terminal-1812315577-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-2)">
+  </text><text class="terminal-1812315577-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-3)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-4)">╭</text><text class="terminal-1812315577-r4" x="158.6" y="117.6" textLength="658.8" clip-path="url(#terminal-1812315577-line-4)">──────────────────────────────────────────────────────</text><text class="terminal-1812315577-r4" x="817.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-4)">╮</text><text class="terminal-1812315577-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-4)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="142" textLength="12.2" clip-path="url(#terminal-1812315577-line-5)">│</text><text class="terminal-1812315577-r5" x="158.6" y="142" textLength="85.4" clip-path="url(#terminal-1812315577-line-5)">Aerilon</text><text class="terminal-1812315577-r4" x="817.4" y="142" textLength="12.2" clip-path="url(#terminal-1812315577-line-5)">│</text><text class="terminal-1812315577-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1812315577-line-5)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-6)">│</text><text class="terminal-1812315577-r6" x="158.6" y="166.4" textLength="85.4" clip-path="url(#terminal-1812315577-line-6)">Aquaria</text><text class="terminal-1812315577-r4" x="817.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-6)">│</text><text class="terminal-1812315577-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-6)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-7)">│</text><text class="terminal-1812315577-r6" x="158.6" y="190.8" textLength="97.6" clip-path="url(#terminal-1812315577-line-7)">Canceron</text><text class="terminal-1812315577-r4" x="817.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-7)">│</text><text class="terminal-1812315577-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-7)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-8)">│</text><text class="terminal-1812315577-r6" x="158.6" y="215.2" textLength="85.4" clip-path="url(#terminal-1812315577-line-8)">Caprica</text><text class="terminal-1812315577-r4" x="817.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-8)">│</text><text class="terminal-1812315577-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-8)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-9)">│</text><text class="terminal-1812315577-r6" x="158.6" y="239.6" textLength="85.4" clip-path="url(#terminal-1812315577-line-9)">Gemenon</text><text class="terminal-1812315577-r4" x="817.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-9)">│</text><text class="terminal-1812315577-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-9)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="264" textLength="12.2" clip-path="url(#terminal-1812315577-line-10)">│</text><text class="terminal-1812315577-r6" x="158.6" y="264" textLength="73.2" clip-path="url(#terminal-1812315577-line-10)">Leonis</text><text class="terminal-1812315577-r4" x="817.4" y="264" textLength="12.2" clip-path="url(#terminal-1812315577-line-10)">│</text><text class="terminal-1812315577-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1812315577-line-10)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-11)">│</text><text class="terminal-1812315577-r6" x="158.6" y="288.4" textLength="73.2" clip-path="url(#terminal-1812315577-line-11)">Libran</text><text class="terminal-1812315577-r4" x="817.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-11)">│</text><text class="terminal-1812315577-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-11)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-12)">│</text><text class="terminal-1812315577-r6" x="158.6" y="312.8" textLength="61" clip-path="url(#terminal-1812315577-line-12)">Picon</text><text class="terminal-1812315577-r4" x="817.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-12)">│</text><text class="terminal-1812315577-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-12)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-13)">│</text><text class="terminal-1812315577-r6" x="158.6" y="337.2" textLength="122" clip-path="url(#terminal-1812315577-line-13)">Sagittaron</text><text class="terminal-1812315577-r4" x="817.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-13)">│</text><text class="terminal-1812315577-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-13)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-14)">│</text><text class="terminal-1812315577-r6" x="158.6" y="361.6" textLength="85.4" clip-path="url(#terminal-1812315577-line-14)">Scorpia</text><text class="terminal-1812315577-r4" x="817.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-14)">│</text><text class="terminal-1812315577-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-14)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="386" textLength="12.2" clip-path="url(#terminal-1812315577-line-15)">│</text><text class="terminal-1812315577-r6" x="158.6" y="386" textLength="73.2" clip-path="url(#terminal-1812315577-line-15)">Tauron</text><text class="terminal-1812315577-r4" x="817.4" y="386" textLength="12.2" clip-path="url(#terminal-1812315577-line-15)">│</text><text class="terminal-1812315577-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1812315577-line-15)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-16)">│</text><text class="terminal-1812315577-r6" x="158.6" y="410.4" textLength="73.2" clip-path="url(#terminal-1812315577-line-16)">Virgon</text><text class="terminal-1812315577-r4" x="817.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-16)">│</text><text class="terminal-1812315577-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-16)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-17)">│</text><text class="terminal-1812315577-r4" x="817.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-17)">│</text><text class="terminal-1812315577-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-17)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-18)">│</text><text class="terminal-1812315577-r4" x="817.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-18)">│</text><text class="terminal-1812315577-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1812315577-line-18)">
+  </text><text class="terminal-1812315577-r4" x="146.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-19)">╰</text><text class="terminal-1812315577-r4" x="158.6" y="483.6" textLength="658.8" clip-path="url(#terminal-1812315577-line-19)">──────────────────────────────────────────────────────</text><text class="terminal-1812315577-r4" x="817.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-19)">╯</text><text class="terminal-1812315577-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1812315577-line-19)">
+  </text><text class="terminal-1812315577-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1812315577-line-20)">
+  </text><text class="terminal-1812315577-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1812315577-line-21)">
+  </text><text class="terminal-1812315577-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1812315577-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
+# name: test_option_list.1
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1362011454-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1362011454-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1362011454-r1 { fill: #c5c8c6 }
+  .terminal-1362011454-r2 { fill: #e3e3e3 }
+  .terminal-1362011454-r3 { fill: #e1e1e1 }
+  .terminal-1362011454-r4 { fill: #004578 }
+  .terminal-1362011454-r5 { fill: #e0e8ee;font-weight: bold }
+  .terminal-1362011454-r6 { fill: #e2e3e3 }
+  .terminal-1362011454-r7 { fill: #a7a9ab }
+  .terminal-1362011454-r8 { fill: #777a7e }
+  .terminal-1362011454-r9 { fill: #14191f }
+  .terminal-1362011454-r10 { fill: #ddedf9 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1362011454-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1362011454-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1362011454-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1362011454-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">OptionListApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1362011454-clip-terminal)">
+      <rect fill="#282828" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="1.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="85.4" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="97.6" y="1.5" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="390.4" y="1.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="549" y="1.5" width="305" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="99.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="123.5" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="244" y="123.5" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="147.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="147.9" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="172.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="196.7" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="256.2" y="196.7" width="536.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="221.1" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="221.1" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="245.5" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="269.9" width="85.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="244" y="269.9" width="549" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="294.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="318.7" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="318.7" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="318.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="343.1" width="73.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="231.8" y="343.1" width="561.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="367.5" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="391.9" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="219.6" y="391.9" width="573.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="416.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="440.7" width="122" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="280.6" y="440.7" width="512.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="465.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1362011454-matrix">
+      <text class="terminal-1362011454-r2" x="12.2" y="20" textLength="12.2" clip-path="url(#terminal-1362011454-line-0)">⭘</text><text class="terminal-1362011454-r2" x="390.4" y="20" textLength="158.6" clip-path="url(#terminal-1362011454-line-0)">OptionListApp</text><text class="terminal-1362011454-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1362011454-line-0)">
+  </text><text class="terminal-1362011454-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-1)">
+  </text><text class="terminal-1362011454-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-2)">
+  </text><text class="terminal-1362011454-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-3)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-4)">╭</text><text class="terminal-1362011454-r4" x="158.6" y="117.6" textLength="658.8" clip-path="url(#terminal-1362011454-line-4)">──────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-4)">╮</text><text class="terminal-1362011454-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-4)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="142" textLength="12.2" clip-path="url(#terminal-1362011454-line-5)">│</text><text class="terminal-1362011454-r5" x="158.6" y="142" textLength="85.4" clip-path="url(#terminal-1362011454-line-5)">Aerilon</text><text class="terminal-1362011454-r4" x="817.4" y="142" textLength="12.2" clip-path="url(#terminal-1362011454-line-5)">│</text><text class="terminal-1362011454-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1362011454-line-5)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-6)">│</text><text class="terminal-1362011454-r6" x="158.6" y="166.4" textLength="85.4" clip-path="url(#terminal-1362011454-line-6)">Aquaria</text><text class="terminal-1362011454-r4" x="817.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-6)">│</text><text class="terminal-1362011454-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-6)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-7)">│</text><text class="terminal-1362011454-r7" x="158.6" y="190.8" textLength="634.4" clip-path="url(#terminal-1362011454-line-7)">────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-7)">│</text><text class="terminal-1362011454-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-7)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-8)">│</text><text class="terminal-1362011454-r6" x="158.6" y="215.2" textLength="97.6" clip-path="url(#terminal-1362011454-line-8)">Canceron</text><text class="terminal-1362011454-r4" x="817.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-8)">│</text><text class="terminal-1362011454-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-8)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-9)">│</text><text class="terminal-1362011454-r8" x="158.6" y="239.6" textLength="85.4" clip-path="url(#terminal-1362011454-line-9)">Caprica</text><text class="terminal-1362011454-r4" x="817.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-9)">│</text><text class="terminal-1362011454-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-9)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="264" textLength="12.2" clip-path="url(#terminal-1362011454-line-10)">│</text><text class="terminal-1362011454-r7" x="158.6" y="264" textLength="634.4" clip-path="url(#terminal-1362011454-line-10)">────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="264" textLength="12.2" clip-path="url(#terminal-1362011454-line-10)">│</text><text class="terminal-1362011454-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1362011454-line-10)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-11)">│</text><text class="terminal-1362011454-r6" x="158.6" y="288.4" textLength="85.4" clip-path="url(#terminal-1362011454-line-11)">Gemenon</text><text class="terminal-1362011454-r4" x="817.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-11)">│</text><text class="terminal-1362011454-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-11)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-12)">│</text><text class="terminal-1362011454-r7" x="158.6" y="312.8" textLength="634.4" clip-path="url(#terminal-1362011454-line-12)">────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-12)">│</text><text class="terminal-1362011454-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-12)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-13)">│</text><text class="terminal-1362011454-r6" x="158.6" y="337.2" textLength="73.2" clip-path="url(#terminal-1362011454-line-13)">Leonis</text><text class="terminal-1362011454-r4" x="817.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-13)">│</text><text class="terminal-1362011454-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-13)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-14)">│</text><text class="terminal-1362011454-r6" x="158.6" y="361.6" textLength="73.2" clip-path="url(#terminal-1362011454-line-14)">Libran</text><text class="terminal-1362011454-r4" x="817.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-14)">│</text><text class="terminal-1362011454-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-14)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="386" textLength="12.2" clip-path="url(#terminal-1362011454-line-15)">│</text><text class="terminal-1362011454-r7" x="158.6" y="386" textLength="634.4" clip-path="url(#terminal-1362011454-line-15)">────────────────────────────────────────────────────</text><text class="terminal-1362011454-r9" x="793" y="386" textLength="24.4" clip-path="url(#terminal-1362011454-line-15)">▅▅</text><text class="terminal-1362011454-r4" x="817.4" y="386" textLength="12.2" clip-path="url(#terminal-1362011454-line-15)">│</text><text class="terminal-1362011454-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1362011454-line-15)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-16)">│</text><text class="terminal-1362011454-r6" x="158.6" y="410.4" textLength="61" clip-path="url(#terminal-1362011454-line-16)">Picon</text><text class="terminal-1362011454-r4" x="817.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-16)">│</text><text class="terminal-1362011454-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-16)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-17)">│</text><text class="terminal-1362011454-r7" x="158.6" y="434.8" textLength="634.4" clip-path="url(#terminal-1362011454-line-17)">────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-17)">│</text><text class="terminal-1362011454-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-17)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-18)">│</text><text class="terminal-1362011454-r6" x="158.6" y="459.2" textLength="122" clip-path="url(#terminal-1362011454-line-18)">Sagittaron</text><text class="terminal-1362011454-r4" x="817.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-18)">│</text><text class="terminal-1362011454-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1362011454-line-18)">
+  </text><text class="terminal-1362011454-r4" x="146.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-19)">╰</text><text class="terminal-1362011454-r4" x="158.6" y="483.6" textLength="658.8" clip-path="url(#terminal-1362011454-line-19)">──────────────────────────────────────────────────────</text><text class="terminal-1362011454-r4" x="817.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-19)">╯</text><text class="terminal-1362011454-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1362011454-line-19)">
+  </text><text class="terminal-1362011454-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1362011454-line-20)">
+  </text><text class="terminal-1362011454-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1362011454-line-21)">
+  </text><text class="terminal-1362011454-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1362011454-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
+# name: test_option_list.2
+  '''
+  <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
+      <!-- Generated with Rich https://www.textualize.io -->
+      <style>
+  
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Regular"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Regular.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Regular.woff") format("woff");
+          font-style: normal;
+          font-weight: 400;
+      }
+      @font-face {
+          font-family: "Fira Code";
+          src: local("FiraCode-Bold"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff2/FiraCode-Bold.woff2") format("woff2"),
+                  url("https://cdnjs.cloudflare.com/ajax/libs/firacode/6.2.0/woff/FiraCode-Bold.woff") format("woff");
+          font-style: bold;
+          font-weight: 700;
+      }
+  
+      .terminal-1620527509-matrix {
+          font-family: Fira Code, monospace;
+          font-size: 20px;
+          line-height: 24.4px;
+          font-variant-east-asian: full-width;
+      }
+  
+      .terminal-1620527509-title {
+          font-size: 18px;
+          font-weight: bold;
+          font-family: arial;
+      }
+  
+      .terminal-1620527509-r1 { fill: #c5c8c6 }
+  .terminal-1620527509-r2 { fill: #e3e3e3 }
+  .terminal-1620527509-r3 { fill: #e1e1e1 }
+  .terminal-1620527509-r4 { fill: #004578 }
+  .terminal-1620527509-r5 { fill: #e0e8ee;font-weight: bold;font-style: italic; }
+  .terminal-1620527509-r6 { fill: #e2e3e3 }
+  .terminal-1620527509-r7 { fill: #e0e8ee;font-weight: bold }
+  .terminal-1620527509-r8 { fill: #14191f }
+  .terminal-1620527509-r9 { fill: #e2e3e3;font-style: italic; }
+  .terminal-1620527509-r10 { fill: #e2e3e3;font-weight: bold }
+  .terminal-1620527509-r11 { fill: #ddedf9 }
+      </style>
+  
+      <defs>
+      <clipPath id="terminal-1620527509-clip-terminal">
+        <rect x="0" y="0" width="975.0" height="584.5999999999999" />
+      </clipPath>
+      <clipPath id="terminal-1620527509-line-0">
+      <rect x="0" y="1.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-1">
+      <rect x="0" y="25.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-2">
+      <rect x="0" y="50.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-3">
+      <rect x="0" y="74.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-4">
+      <rect x="0" y="99.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-5">
+      <rect x="0" y="123.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-6">
+      <rect x="0" y="147.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-7">
+      <rect x="0" y="172.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-8">
+      <rect x="0" y="196.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-9">
+      <rect x="0" y="221.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-10">
+      <rect x="0" y="245.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-11">
+      <rect x="0" y="269.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-12">
+      <rect x="0" y="294.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-13">
+      <rect x="0" y="318.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-14">
+      <rect x="0" y="343.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-15">
+      <rect x="0" y="367.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-16">
+      <rect x="0" y="391.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-17">
+      <rect x="0" y="416.3" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-18">
+      <rect x="0" y="440.7" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-19">
+      <rect x="0" y="465.1" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-20">
+      <rect x="0" y="489.5" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-21">
+      <rect x="0" y="513.9" width="976" height="24.65"/>
+              </clipPath>
+  <clipPath id="terminal-1620527509-line-22">
+      <rect x="0" y="538.3" width="976" height="24.65"/>
+              </clipPath>
+      </defs>
+  
+      <rect fill="#292929" stroke="rgba(255,255,255,0.35)" stroke-width="1" x="1" y="1" width="992" height="633.6" rx="8"/><text class="terminal-1620527509-title" fill="#c5c8c6" text-anchor="middle" x="496" y="27">OptionListApp</text>
+              <g transform="translate(26,22)">
+              <circle cx="0" cy="0" r="7" fill="#ff5f57"/>
+              <circle cx="22" cy="0" r="7" fill="#febc2e"/>
+              <circle cx="44" cy="0" r="7" fill="#28c840"/>
+              </g>
+          
+      <g transform="translate(9, 41)" clip-path="url(#terminal-1620527509-clip-terminal)">
+      <rect fill="#282828" x="0" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="12.2" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="24.4" y="1.5" width="61" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="85.4" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="97.6" y="1.5" width="292.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="390.4" y="1.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="549" y="1.5" width="305" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="854" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="0" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="866.2" y="1.5" width="97.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#282828" x="963.8" y="1.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="25.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="50.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="74.7" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="99.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="99.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="99.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="123.5" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="123.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="123.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="123.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="147.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="147.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="147.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="147.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="170.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="183" y="172.3" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="341.6" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="353.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="366" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="378.2" y="172.3" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="549" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="561.2" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="573.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="585.6" y="172.3" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="768.6" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="780.8" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#23568b" x="793" y="172.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="172.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="172.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="196.7" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="196.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="196.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="196.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="170.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="183" y="221.1" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="341.6" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="353.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="366" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="378.2" y="221.1" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="549" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="561.2" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="573.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="585.6" y="221.1" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="768.6" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="780.8" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="221.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="221.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="221.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#125081" x="158.6" y="245.5" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="245.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="245.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="245.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="269.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="269.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="269.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="269.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="294.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="294.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="294.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="294.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="318.7" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="353.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="366" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="378.2" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="390.4" y="318.7" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="549" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="561.2" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="573.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="585.6" y="318.7" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="768.6" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="780.8" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="318.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="318.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="318.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="343.1" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="343.1" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="343.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="343.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="170.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="183" y="367.5" width="170.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="353.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="366" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="378.2" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="390.4" y="367.5" width="158.6" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="549" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="561.2" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="573.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="585.6" y="367.5" width="183" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="768.6" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="780.8" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="367.5" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="367.5" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="367.5" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="391.9" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="391.9" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="391.9" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="391.9" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="416.3" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="416.3" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="416.3" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="416.3" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="440.7" width="634.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#14191f" x="793" y="440.7" width="24.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="440.7" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="440.7" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="146.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="158.6" y="465.1" width="658.8" height="24.65" shape-rendering="crispEdges"/><rect fill="#24292f" x="817.4" y="465.1" width="12.2" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="829.6" y="465.1" width="146.4" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="489.5" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="513.9" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#1e1e1e" x="0" y="538.3" width="976" height="24.65" shape-rendering="crispEdges"/><rect fill="#0178d4" x="0" y="562.7" width="976" height="24.65" shape-rendering="crispEdges"/>
+      <g class="terminal-1620527509-matrix">
+      <text class="terminal-1620527509-r2" x="12.2" y="20" textLength="12.2" clip-path="url(#terminal-1620527509-line-0)">⭘</text><text class="terminal-1620527509-r2" x="390.4" y="20" textLength="158.6" clip-path="url(#terminal-1620527509-line-0)">OptionListApp</text><text class="terminal-1620527509-r1" x="976" y="20" textLength="12.2" clip-path="url(#terminal-1620527509-line-0)">
+  </text><text class="terminal-1620527509-r1" x="976" y="44.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-1)">
+  </text><text class="terminal-1620527509-r1" x="976" y="68.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-2)">
+  </text><text class="terminal-1620527509-r1" x="976" y="93.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-3)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-4)">╭</text><text class="terminal-1620527509-r4" x="158.6" y="117.6" textLength="658.8" clip-path="url(#terminal-1620527509-line-4)">──────────────────────────────────────────────────────</text><text class="terminal-1620527509-r4" x="817.4" y="117.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-4)">╮</text><text class="terminal-1620527509-r1" x="976" y="117.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-4)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="142" textLength="12.2" clip-path="url(#terminal-1620527509-line-5)">│</text><text class="terminal-1620527509-r5" x="158.6" y="142" textLength="634.4" clip-path="url(#terminal-1620527509-line-5)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Data&#160;for&#160;Aerilon&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r4" x="817.4" y="142" textLength="12.2" clip-path="url(#terminal-1620527509-line-5)">│</text><text class="terminal-1620527509-r1" x="976" y="142" textLength="12.2" clip-path="url(#terminal-1620527509-line-5)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-6)">│</text><text class="terminal-1620527509-r7" x="158.6" y="166.4" textLength="634.4" clip-path="url(#terminal-1620527509-line-6)">┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓</text><text class="terminal-1620527509-r4" x="817.4" y="166.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-6)">│</text><text class="terminal-1620527509-r1" x="976" y="166.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-6)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">│</text><text class="terminal-1620527509-r7" x="158.6" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">┃</text><text class="terminal-1620527509-r7" x="183" y="190.8" textLength="158.6" clip-path="url(#terminal-1620527509-line-7)">Patron&#160;God&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="353.8" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">┃</text><text class="terminal-1620527509-r7" x="378.2" y="190.8" textLength="170.8" clip-path="url(#terminal-1620527509-line-7)">Population&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="561.2" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">┃</text><text class="terminal-1620527509-r7" x="585.6" y="190.8" textLength="183" clip-path="url(#terminal-1620527509-line-7)">Capital&#160;City&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="780.8" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">┃</text><text class="terminal-1620527509-r8" x="793" y="190.8" textLength="24.4" clip-path="url(#terminal-1620527509-line-7)">▂▂</text><text class="terminal-1620527509-r4" x="817.4" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">│</text><text class="terminal-1620527509-r1" x="976" y="190.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-7)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-8)">│</text><text class="terminal-1620527509-r7" x="158.6" y="215.2" textLength="634.4" clip-path="url(#terminal-1620527509-line-8)">┡━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩</text><text class="terminal-1620527509-r4" x="817.4" y="215.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-8)">│</text><text class="terminal-1620527509-r1" x="976" y="215.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-8)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r7" x="158.6" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r7" x="183" y="239.6" textLength="158.6" clip-path="url(#terminal-1620527509-line-9)">Demeter&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="353.8" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r7" x="378.2" y="239.6" textLength="170.8" clip-path="url(#terminal-1620527509-line-9)">1.2&#160;Billion&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="561.2" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r7" x="585.6" y="239.6" textLength="183" clip-path="url(#terminal-1620527509-line-9)">Gaoth&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r7" x="780.8" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r4" x="817.4" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">│</text><text class="terminal-1620527509-r1" x="976" y="239.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-9)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="264" textLength="12.2" clip-path="url(#terminal-1620527509-line-10)">│</text><text class="terminal-1620527509-r7" x="158.6" y="264" textLength="634.4" clip-path="url(#terminal-1620527509-line-10)">└───────────────┴────────────────┴─────────────────┘</text><text class="terminal-1620527509-r4" x="817.4" y="264" textLength="12.2" clip-path="url(#terminal-1620527509-line-10)">│</text><text class="terminal-1620527509-r1" x="976" y="264" textLength="12.2" clip-path="url(#terminal-1620527509-line-10)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-11)">│</text><text class="terminal-1620527509-r9" x="158.6" y="288.4" textLength="634.4" clip-path="url(#terminal-1620527509-line-11)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Data&#160;for&#160;Aquaria&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r4" x="817.4" y="288.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-11)">│</text><text class="terminal-1620527509-r1" x="976" y="288.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-11)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-12)">│</text><text class="terminal-1620527509-r6" x="158.6" y="312.8" textLength="634.4" clip-path="url(#terminal-1620527509-line-12)">┏━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓</text><text class="terminal-1620527509-r4" x="817.4" y="312.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-12)">│</text><text class="terminal-1620527509-r1" x="976" y="312.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-12)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">│</text><text class="terminal-1620527509-r6" x="158.6" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">┃</text><text class="terminal-1620527509-r10" x="183" y="337.2" textLength="170.8" clip-path="url(#terminal-1620527509-line-13)">Patron&#160;God&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="366" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">┃</text><text class="terminal-1620527509-r10" x="390.4" y="337.2" textLength="158.6" clip-path="url(#terminal-1620527509-line-13)">Population&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="561.2" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">┃</text><text class="terminal-1620527509-r10" x="585.6" y="337.2" textLength="183" clip-path="url(#terminal-1620527509-line-13)">Capital&#160;City&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="780.8" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">┃</text><text class="terminal-1620527509-r4" x="817.4" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">│</text><text class="terminal-1620527509-r1" x="976" y="337.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-13)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-14)">│</text><text class="terminal-1620527509-r6" x="158.6" y="361.6" textLength="634.4" clip-path="url(#terminal-1620527509-line-14)">┡━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━┩</text><text class="terminal-1620527509-r4" x="817.4" y="361.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-14)">│</text><text class="terminal-1620527509-r1" x="976" y="361.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-14)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r6" x="158.6" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r6" x="183" y="386" textLength="170.8" clip-path="url(#terminal-1620527509-line-15)">Hermes&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="366" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r6" x="390.4" y="386" textLength="158.6" clip-path="url(#terminal-1620527509-line-15)">75,000&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="561.2" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r6" x="585.6" y="386" textLength="183" clip-path="url(#terminal-1620527509-line-15)">None&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r6" x="780.8" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r4" x="817.4" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">│</text><text class="terminal-1620527509-r1" x="976" y="386" textLength="12.2" clip-path="url(#terminal-1620527509-line-15)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-16)">│</text><text class="terminal-1620527509-r6" x="158.6" y="410.4" textLength="634.4" clip-path="url(#terminal-1620527509-line-16)">└────────────────┴───────────────┴─────────────────┘</text><text class="terminal-1620527509-r4" x="817.4" y="410.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-16)">│</text><text class="terminal-1620527509-r1" x="976" y="410.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-16)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-17)">│</text><text class="terminal-1620527509-r9" x="158.6" y="434.8" textLength="634.4" clip-path="url(#terminal-1620527509-line-17)">&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;Data&#160;for&#160;Canceron&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;&#160;</text><text class="terminal-1620527509-r4" x="817.4" y="434.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-17)">│</text><text class="terminal-1620527509-r1" x="976" y="434.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-17)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-18)">│</text><text class="terminal-1620527509-r6" x="158.6" y="459.2" textLength="634.4" clip-path="url(#terminal-1620527509-line-18)">┏━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┓</text><text class="terminal-1620527509-r4" x="817.4" y="459.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-18)">│</text><text class="terminal-1620527509-r1" x="976" y="459.2" textLength="12.2" clip-path="url(#terminal-1620527509-line-18)">
+  </text><text class="terminal-1620527509-r4" x="146.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-19)">╰</text><text class="terminal-1620527509-r4" x="158.6" y="483.6" textLength="658.8" clip-path="url(#terminal-1620527509-line-19)">──────────────────────────────────────────────────────</text><text class="terminal-1620527509-r4" x="817.4" y="483.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-19)">╯</text><text class="terminal-1620527509-r1" x="976" y="483.6" textLength="12.2" clip-path="url(#terminal-1620527509-line-19)">
+  </text><text class="terminal-1620527509-r1" x="976" y="508" textLength="12.2" clip-path="url(#terminal-1620527509-line-20)">
+  </text><text class="terminal-1620527509-r1" x="976" y="532.4" textLength="12.2" clip-path="url(#terminal-1620527509-line-21)">
+  </text><text class="terminal-1620527509-r1" x="976" y="556.8" textLength="12.2" clip-path="url(#terminal-1620527509-line-22)">
+  </text>
+      </g>
+      </g>
+  </svg>
+  
+  '''
+# ---
 # name: test_order_independence
   '''
   <svg class="rich-terminal" viewBox="0 0 994 635.5999999999999" xmlns="http://www.w3.org/2000/svg">
diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py
index 7b32d62aa5..a329811d09 100644
--- a/tests/snapshot_tests/test_snapshots.py
+++ b/tests/snapshot_tests/test_snapshots.py
@@ -187,6 +187,11 @@ def test_tabbed_content(snap_compare):
     assert snap_compare(WIDGET_EXAMPLES_DIR / "tabbed_content.py")
 
 
+def test_option_list(snap_compare):
+    assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_strings.py")
+    assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py")
+    assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
+
 # --- CSS properties ---
 # We have a canonical example for each CSS property that is shown in their docs.
 # If any of these change, something has likely broken, so snapshot each of them.


Code review comments:
diff_hunk: @@ -0,0 +1,904 @@
+"""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, Iterator, NamedTuple
+
+from rich.console import RenderableType
+from rich.repr import Result
+from rich.rule import Rule
+from rich.segment import Segment
+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 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: An 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:
+        """An 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 that denotes that a particular option is really a separator."""
+
+
+class Line(NamedTuple):
+    """Class that holds a list of segments for the line of a option."""
+
+    segments: list[Segment]
+    """The list 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 represnets all of the types that will be accepted when adding new
path: src/textual/widgets/_option_list.py
text: @user1:
```suggestion
This type represents all of the types that will be accepted when adding new
```

diff_hunk: @@ -0,0 +1,904 @@
+"""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, Iterator, NamedTuple
+
+from rich.console import RenderableType
+from rich.repr import Result
+from rich.rule import Rule
+from rich.segment import Segment
+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 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: An 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:
+        """An 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 that denotes that a particular option is really a separator."""
+
+
+class Line(NamedTuple):
+    """Class that holds a list of segments for the line of a option."""
+
+    segments: list[Segment]
+    """The list 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 represnets 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", "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", "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: $panel-lighten-2;
+    }
+
+    OptionList > .option-list--option-hover-disabled {
+        color: $text-disabled;
+        background: $panel-lighten-2;
+    }
+
+    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 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, old: bool, new: bool) -> None:
+        """Handle the vertical scrollbar visibility status changing.
+
+        Args:
+            old: The old status.
+            new: The new status.
+
+        `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.
+        """
+        if old != new:
+            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.
+
+        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 = 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(prompt_line, 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.
+        """
+        # 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 open with the given ID.
path: src/textual/widgets/_option_list.py
text: @user1:
```suggestion
        """Remove the option with the given ID.
```

diff_hunk: @@ -0,0 +1,904 @@
+"""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, Iterator, NamedTuple
+
+from rich.console import RenderableType
+from rich.repr import Result
+from rich.rule import Rule
+from rich.segment import Segment
+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 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: An 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:
+        """An 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 that denotes that a particular option is really a separator."""
+
+
+class Line(NamedTuple):
+    """Class that holds a list of segments for the line of a option."""
+
+    segments: list[Segment]
+    """The list 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 represnets 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", "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", "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: $panel-lighten-2;
+    }
+
+    OptionList > .option-list--option-hover-disabled {
+        color: $text-disabled;
+        background: $panel-lighten-2;
+    }
+
+    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 at least the same as the number
path: src/textual/widgets/_option_list.py
text: @user1:
```suggestion
        Note that the size of this list will be at least the same as the number
```