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