mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Add OptionList widget (#2154)
* Menu skeleton The vaguest of starts. Near the end of the day and I want to pick this up later/tomorrow, so making sure it's on the forge before I go AFK. * Initial design for populating the menu One of the driving forces in the design here is that a menu option can have a prompt that is more than one line in height; and different options can have different height prompts. This has meant that I've had to finally get to grips with Rich renderable types and related things. The menu is going to lean heavily on the line API, and aims to be as efficient as possible when it comes to having a very large menu. Large menus are a bad idea! Nobody should be using large menus. On the other hand, people will do it so let's allow for it. Work in progress commit. Lots more to come. * Add a reminder about width * Make mypy and friend happy with OptionLineSpan.__contains__ * Fix typo * Add a debug message I'll remove this later, but I'd like to bubble up some debug stuff into my own test app. * Get scrolling working After battling for ages to try and figure out why scrolling just wasn't working at all, two things turned out to be at play: 1. If `overflow: hidden` you need force=True. Doh! - I should know that too as I added force. O_o 2. Even if you do the above, it doesn't work as you'd expect *if* animation is turned on. Turning that off made things work. I've raised #2077 as a reminder to myself that I need to look into '2' some more. The menus feel very snappy with animation off, but I suspect there will be a call to allow animation during menu navigation so that'll need sorting at some point I guess. * Add support for home and end keys Which, shockingly, go to the first and last items in the menu. * Remind myself I need to remove the Debug message Once I'm done with it. * Make the MenuOption class a NamedTuple There's no obvious benefit to it being a full class, but some benefits to it being a NamedTuple. * Add a Menu.OptionHighlighted message * Add a missing argument to a docstring * Fully type the option line segments list I forgot to ensure that it was fully typed. * Add a method for getting an option at a given position * Better name for the location of the option * Include the highlighted index in the OptionHighlighted message * Add home and end to the binding docstring * Add support for page up/down in a menu * Rename OptionLineSegments to OptionLine It's the details for a line of an option that hold the segments, amongst other things. No point in repeating information here. * Add support for a Menu.OptionSelected message * Remove a TODO comment It's not that it doesn't need doing yet; it's just that I've moved some TODO stuff to a WIP document. * Document the enter binding * Add a reminder to myself about why animate is off For a menu I feel that animate *should* be off, but if anyone is reading this bit of code and feels it should be on, this will explain why it isn't. * Explain OptionLineSpan a wee bit more * Import Literal from typing_extensions rather than typing This is needed for older Pythons. * Move the prompt shapes calculation code into its own method * Add a property for getting the option count of the menu * Add a method of adding an option to the menu * Highlight first option if no highlight and then movement * Ensure the virtual height is recalculated on addition * Remove the method for getting a specific option There's little point in letting the user treat the menu as if it's an array; they should know what they put into it and anything that happens *on* the menu will result in a message which will point to the option anyway. * Add a menu separator This isn't the complete version of this; aside from the obvious fact that at the moment it's just treated line an ordinary menu option (which we don't want), the presence of a separator means the index of options will be thrown off, from the user's point of view. The point being, a menu with 4 options and a separator might look like this: Option 1 Option 2 -------- Option 3 Option 4 I think the index of "Option 3" above should likely be 2 (starting from 0, of course), not 3. This means I need to tweak the internals of the menu code to take this into account while also keeping things efficient. That's next up; but I wanted to get the core of this change in first so I can noodle away and get the best approach to this. * Finish off support for menu separators Here I sort of add support for a menu having both content *and* prompts. Content is anything that goes in the menu and results in lines being rendered. Prompts are things that the end user actually gets to select from. A menu option will have a prompt that has one or more lines. A menu separator isn't an option but takes up one line. * Add (back) a method for getting a particular option Now that the menu content and the menu options are different lists, it's possible to allow this again. While it still follows that menus shouldn't really be treated like lists, there's no harm in providing this facility. * Allow styling separators * Fix how we tell the Rule to have no style * Add a documentation line for the separator component class * Apply default styling to the non-special options * Set the default color to $text This isn't actually working, but if I set it to an actual colour, it does work. Need to dig into this more. * Remove a TODO warning that isn't valid any more * Have the menu option messages get the option via Menu.option It did, and still could, pull directly from the _option property but one step of indirection means that I can be sure anything "external" is going via the public interface (yes, I know the message isn't really "external" but it feels correct to treat it as such because it's for public consumption). * Make OptionLine just a Line and drop magic numbers for separators Rather than overload the option index of the option line class with a magic number to say that something isn't really an option, here I make the class just about being a line, I keep the option_index but make it optional (no pun intended); so that if it's `None` that means "this isn't related to a menu option". * Drop the assert that non-option content is a separator I can't see much benefit in doing this in what should be a fairly tight loop. This code relates to data that's all under the hood so we shouldn't need to be quite so defensive. * Remove unused import of Final * Add a clear method * Add support for disabled menu options At the moment this is done in a way that, as the user navigates, the disabled options *aren't* skipped. I'm still undecided about this. Your traditional dropdown menus sometimes do that, sometimes don't do that. And to make things even more interesting this menu can really be used as a large-data-friendly listbox and I'm not sure we'd want that there. This may change. Also, at this point, I'm also working to keep the MenuOption class a NamedTuple, which means it's read-only (I don't want the user messing with things outside of the menu), which means there's interface methods for changing the disabled state that copy the option and change the disabled state. Again, I'll see if I carry on liking this or not. So far I'm okay with this. * Dial in the styles some more * Rename some methods that use index to say index in the name I'm going to be adding support for an id for options too, and want the user to be able to either access an option via index or via ID. This is the first step to allowing that. * Remove an unnecessary inherit Looks like this was a hangover from an early version of the message classes and I didn't clean up. * Fix copy/paste-o Now there is forever evidence as to where I stole my homework from. * Add support for menu option IDs The idea here is that they're purely from, and purely for, the developer who is creating the menu. Internally I don't care about them and don't personally use them. However, there is without a doubt a good case for allowing the developer to specify IDs for options so here's optional support for that. * Add a method to get a menu option via an ID * Reduce property access and list access calls * Have the content-tracker code do a little less work * Drop the menu option data attribute It wasn't going to work well, was going to cause a bunch of problems with typing, and really it's easier to do by the dev by having them inherit from MenuOption. So let's do that. * Remove unused imports * Add a TODO reminder about subclasses menu optons * Add an initial bit of unit testing Just the most basic test so far; it's the end of the day but I want to start here. * Allow the caller to use None as an alias for MenuSeparator In doing so, overhaul how I type candidate menu content vs actual menu content, setting up a couple of type aliases and making it easier to maintain. * Rename the parameter for Menu.add to better match other changes * Swap MenuOption over to being a standard class It would have been nice to keep it as a named tuple, but I want the developer to be able to subclass and add their own properties to the option (think attaching some random data to a menu option). The problem is you can't subclass a named tuple. So... standard class it is, with some reasonably defensive work to discourage the developer from changing the prompt and the ID on the fly. For obvious reasons I need to let them change the disabled state on the fly, and this is where things end up being a little iffy. The only way (right now) the menu will refresh when the disabled state changes is if the developer does so via one of the methods on Menu. If they go toggling the state on the option itself and hope that the menu will reflect this... no, that's not going to happen. I *could* make it happen by somehow capturing a reference to the parent menu inside the menu option, but then things get circular and I don't like that. * Test using None as an alternative to MenuSeparator * Flesh out the initial menu unit tests * Add a module docstring to the core menu unit test * Add some testing for using subclassed menu options * Add a property for getting an iterator of the options * Add unit tests for option enabled/disabled * Rename Menu -> OptionList (and friends) The great renaming! We sort of had decided this was coming, but kept going back and forth on if we should, what it should be, etc. Decision made today. While this is mostly everything you want from a menu, it is foundational enough that it needs to really be something else so it's a list of options. Options; in a list. An OptionList. * Add a test for adding more items to an option list later on * Remove the debug message I think I'm at a stage where I don't need to use it any more. * Start of OptionList movement tests End of day commit; more to come. * Update the pyi for the Menu -> OptionList rename Missed this during the grand rename. * Tidy up a test * Export the DuplicateID exception * Add a test for creating a duplicate ID * Add some more OptionList movement tests * Allow scrollbars by default Until the great renaming, this code was all about being menus, which normally don't have scrollbars, and so I made a point of not having them on. Now that this code is more about it being a list of stuff, which can be the foundation for a menu, we want the bars there by default and any derived menu widget can turn them off. So here we go. This introduces some issues that now need to be addressed. For one thing no thought has been given to horizontal scrolling in this code (easy enough to solve). Also, weirdly though, the vertical scrollbars aren't quite reaching the bottom when we highlight the last item. Wasn't expecting that, although I'm sure there's a simple cause for that. * Remove hover component class I do want this, but not yet, so don't have it kicking around until I'm actually doing something with it. * Add missing items to the component classes docstring. * Crop the lines that we draw This in turn adds support for horizontal scrolling. We're not actually going to support horizontal scrolling; in conversation with Will we've decided that it will be *only* a vertical scrolling list, so options will be rendered within the confines of the width. * Allow for scrollbars by default * Make scrolling to a non-highlight a nop rather than an error Being able to call scroll_to_highlight even if nothing is highlighted is useful; throwing an error when something isn't, isn't helpful. So let's make that a no-op. * Make a note that option ID tracking could be changed * Ensure highlight is pulled into view on resize It's possible that a resize might cause a highlight to partially, or even totally, go out of view. This commit ensures that after such an event this will be handled. * Save an attribute access * Microoptimise _refresh_content_tracking some more * Reintroduce animation But only if the vertical scrollbar is visible (see #2077 for context). * Force a refresh when doing a specific add * Add support for a mouse hover effect * Highlighted a clicked option (where appropriate) * Improve the style of a focused highlighted hovered option * Reduce the number of attribute lookups in the line drawing method * Simplify the way we handle page up/down at the margins Rather than wrap around when doing page up/down, have them work as home/end when at the margins. * Remove unnecessary import * Add some more option list movement tests * Add tests for moving around an empty list * Remove the debug message (again) * Test moving when there are items but no highlight * Ensure the mouse over gets cleared on clear * Remove mouse hover logging code It was useful while adding mouse hover support, but it's not needed now. * Force a refresh of content tracking when doing a clear * Rename some methods to talk about options I want to add a `remove` for options, but widgets already have a `remove`. So I could call it `remove_option` but then that's an imbalance with `add`. So this renames `add` to `add_option`, and also renames `clear` to `clear_options`. * Add support for removing an option * Add highlight wrapping back I made some recent changes to highlight validation where more sensible in the general sense, but broke the wrapping when using cursor keys to move around. This takes that into account. * Add tests for removing options * Reduce the number of attribute lookups for spans * Swap to watching highlighted to handle movement I'd started out with an explicit refresh of the highlighted option, while working on other things, and forgot to swap over to using a watch method. This commit fixes that. * Make a mouse-clicked option select that option too * Add unit tests for option list messages * Add unit tests for mouse hover events * Clarify the point of the mouse click test * Add an option list message test for highlighting a disabled option * Add tests for interacting with disabled OptionList options * Typo fixing Try and make the docstring sound something approaching English. * Fix the OptionMessage.__init__ docstring * Add the API documentation for the OptionList * Update the OptionTest message tests for initial highlight Having changed things around a little regarding initial highlight, the unit tests needed updating. * Start the reference for the OptionList I feel this needs a bit more work, but this feels like the core of what we want to be emphasising. * Add the OptionList to the gallary * Try some extra pauses in OptionList tests While the tests are all passing just fine locally, I'm getting the whole whack-a-mole thing in CI that is mostly down to subtle timing issues. This is a test to see if these extra pauses let the test apps settle down before starting the meat of the testing. * Try pausing in tests without setting a time * Add snapshot tests for the OptionList examples * Sort the bindings * Add a docstring to the default CSS * Explain that mouse_hovering_over can be None * Turn mouse_hovering_over into an internal property There was a reason that I had it as a reactive, at one point, but looking at the final form of this code I can't see a use for it any more. So bring it internal and make it cheaper to update. * Update the CHANGELOG * Update the mouse hover test after the changes to the tracking variable * Tweak the descriptions of the hover tests Now that I've changed this away from being a reactive. * Tweak the OptionList hover tests some more * Rename the up/down actions to cursor_up/down Re: https://github.com/Textualize/textual/pull/2154#discussion_r1151587080 * Don't kick off an idle check if the widget isn't running Added at Will's suggestion. :-P * Simplify how we watch the vertical scrollbar status Re: https://github.com/Textualize/textual/pull/2154#discussion_r1151593625 * Change the hover highlight to $boost Re: https://github.com/Textualize/textual/pull/2154#discussion_r1151628190 * Add a custom exception for when an option can't be found Re:74a2d079b3 (r1151632957)and74a2d079b3 (r1151631495)* Update tests for the new option list exceptions * Remove the options property We've decided it has little utility given the rest of the interface of the widget. Re: https://github.com/Textualize/textual/pull/2154#discussion_r1151630437 * Remove import of iterator It's no longer required. * Fix some option list unit tests after removing options property * Crate Line.segments as a strip Rather than recreate the strip every time around, just create it as a Strip to start with. Also, in doing so, add the option meta up front rather than every time we draw the line. Re: https://github.com/Textualize/textual/pull/2154#discussion_r1151600239 * Correct a comment typo * Simplify the Separator docstring * Docstring wording tweak * Remove the import of Segment It's not needed any more * Flesh out the OptionList reference some more Things like the component classes, bindings and messages had been left out. * Update snapshot tests Nothing of consequence has changed but it looked like the change to how the lines are originally constructed has resulted in an under-the-hood change to the data that goes into a snapshot. * Add a missing word to a docstring Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Fix a typo Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Fix a typo Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> * Document some raises that were missing * Turn off animation --------- Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
3
docs/api/option_list.md
Normal file
3
docs/api/option_list.md
Normal file
@@ -0,0 +1,3 @@
|
||||
::: textual.widgets.OptionList
|
||||
::: textual.widgets._option_list.Option
|
||||
::: textual.widgets._option_list.Separator
|
||||
10
docs/examples/widgets/option_list.css
Normal file
10
docs/examples/widgets/option_list.css
Normal file
@@ -0,0 +1,10 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
OptionList {
|
||||
background: $panel;
|
||||
border: round $primary;
|
||||
width: 70%;
|
||||
height: 70%;
|
||||
}
|
||||
36
docs/examples/widgets/option_list_options.py
Normal file
36
docs/examples/widgets/option_list_options.py
Normal file
@@ -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()
|
||||
28
docs/examples/widgets/option_list_strings.py
Normal file
28
docs/examples/widgets/option_list_strings.py
Normal file
@@ -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()
|
||||
44
docs/examples/widgets/option_list_tables.py
Normal file
44
docs/examples/widgets/option_list_tables.py
Normal file
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
127
docs/widgets/option_list.md
Normal file
127
docs/widgets/option_list.md
Normal file
@@ -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
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
944
src/textual/widgets/_option_list.py
Normal file
944
src/textual/widgets/_option_list.py
Normal file
@@ -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))
|
||||
3
src/textual/widgets/option_list.py
Normal file
3
src/textual/widgets/option_list.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._option_list import DuplicateID, Option, OptionDoesNotExist, Separator
|
||||
|
||||
__all__ = ["DuplicateID", "Option", "OptionDoesNotExist", "Separator"]
|
||||
115
tests/option_list/test_option_list_create.py
Normal file
115
tests/option_list/test_option_list_create.py
Normal file
@@ -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"))
|
||||
80
tests/option_list/test_option_list_disabled.py
Normal file
80
tests/option_list/test_option_list_disabled.py
Normal file
@@ -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
|
||||
51
tests/option_list/test_option_list_mouse_hover.py
Normal file
51
tests/option_list/test_option_list_mouse_hover.py
Normal file
@@ -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
|
||||
159
tests/option_list/test_option_list_movement.py
Normal file
159
tests/option_list/test_option_list_movement.py
Normal file
@@ -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
|
||||
38
tests/option_list/test_option_list_option_subclass.py
Normal file
38
tests/option_list/test_option_list_option_subclass.py
Normal file
@@ -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
|
||||
120
tests/option_list/test_option_messages.py
Normal file
120
tests/option_list/test_option_messages.py
Normal file
@@ -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:] == []
|
||||
85
tests/option_list/test_option_removal.py
Normal file
85
tests/option_list/test_option_removal.py
Normal file
@@ -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
|
||||
File diff suppressed because one or more lines are too long
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user