I have a Textual SelectionList
in my Python console app:
...
yield SelectionList[str](
*tuple(self.selection_items),
id="mylist")
...
In my associated .tcss
(Textual CSS) how would I target the text of a selected item? I'd like all selected items to have a different text colour.
Interpreting the source of SelectionList
, OptionList
and ToggleButton
I've tried a variety of selectors targeting the -on
variant of the ToggleButton
child of the SelectionList
component (globally and via an #mylist
id selector, and while I can change the colour for all items I'm unable to restrict it to just the selected ones.
While it's not currently possible (Textual v3.1.0) to directly target a selected option in a SelectionList
, following advice from the project Discord discussion, it is possible to subclass the existing SelectionList
and add the feature directly.
This also requires subclassing OptionList
(used by SelectionList
). This also gives you the ability to customise the selected/unselected characters the option list uses. The following is not pretty, but does the job. Probably best placed in a separate file. I've removed the comments copied over from the source for brevity.
from rich.segment import Segment
from rich.style import Style
from textual.strip import Strip
from textual.widgets import SelectionList
from textual.widgets._option_list import (OptionDoesNotExist, OptionList)
from textual.widgets._toggle_button import ToggleButton
from typing import TYPE_CHECKING, ClassVar
class StyledSelectionList[str](SelectionList):
"""
A styled SelectionList. Copied with modififications from Textual v3.1.0 source
"""
class StyledOptionList(OptionList):
COMPONENT_CLASSES: ClassVar[set[str]] = {
"option-list--option",
"option-list--option-selected",
"option-list--option-disabled",
"option-list--option-highlighted",
"option-list--option-hover",
"option-list--separator",
}
def render_line(self, y: int, is_selected) -> Strip:
line_number = self.scroll_offset.y + y
try:
option_index, line_offset = self._lines[line_number]
option = self.options[option_index]
mouse_over = self._mouse_hovering_over == option_index
component_class = ""
if is_selected:
component_class = "option-list--option-selected"
elif option.disabled:
component_class += "option-list--option-disabled"
elif self.highlighted == option_index:
component_class += "option-list--option-highlighted"
elif mouse_over:
component_class += "option-list--option-hover"
if component_class:
style = self.get_visual_style("option-list--option", component_class)
else:
style = self.get_visual_style("option-list--option")
strips = self._get_option_render(option, style)
try:
strip = strips[line_offset]
except IndexError:
return Strip.blank(self.scrollable_content_region.width)
return strip
except IndexError:
return Strip.blank(self.scrollable_content_region.width)
SELECTED_CHARS = ToggleButton.BUTTON_INNER
UNSELECTED_CHARS = ToggleButton.BUTTON_INNER
def render_line(self, y: int) -> Strip:
line = self.StyledOptionList.render_line(self,y, None)
_, scroll_y = self.scroll_offset
selection_index = scroll_y + y
try:
selection = self.get_option_at_index(selection_index)
line = self.StyledOptionList.render_line(self,y, selection.id in self._selected)
component_style = "selection-list--button"
if selection.value in self._selected:
component_style += "-selected"
if self.highlighted == selection_index:
component_style += "-highlighted"
underlying_style = next(iter(line)).style or self.rich_style
assert underlying_style is not None
button_style = self.get_component_rich_style(component_style)
side_style = Style.from_color(button_style.bgcolor, underlying_style.bgcolor)
side_style += Style(meta={"option": selection_index})
button_style += Style(meta={"option": selection_index})
return Strip(
[
Segment(ToggleButton.BUTTON_LEFT, style=side_style),
Segment(self.SELECTED_CHARS if selection.id in self._selected else self.UNSELECTED_CHARS, style=button_style),
Segment(ToggleButton.BUTTON_RIGHT, style=side_style),
Segment(" ", style=underlying_style),
*line,
]
)
except OptionDoesNotExist:
return line
And use it as-is for the default SelectionList
behaviour, or inherit to change the default characters:
class MyStyledSelectionList[str](StyledSelectionList):
SELECTED_CHARS = "✅"
UNSELECTED_CHARS = " " # Note: two spaces here to match the emoji width
You can target the selected option text style with the option-list--option-selected
class:
.option-list--option-selected {
color: ansi_bright_green;
}
A minimal-ish (but self-contained), app showcasing the functionality looks like:
from styledSelectionList import StyledSelectionList
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Container
from textual.widgets import SelectionList
from textual.widgets.selection_list import Selection
selection_items = [
Selection(
f'{item["name"]}',
item["id"],
item["enabled"],
id=f'{item["id"]}'
) for item in [
{ "name": "item1", "id" : "item1", "enabled" : False },
{ "name": "item2", "id" : "item2", "enabled" : True },
{ "name": "item3", "id" : "item3", "enabled" : False },
]
]
class MyStyledSelectionList1[str](StyledSelectionList):
SELECTED_CHARS = "✅"
UNSELECTED_CHARS = " "
class MyStyledSelectionList2[str](StyledSelectionList):
SELECTED_CHARS = "N"
UNSELECTED_CHARS = "Y"
class MyApp(App):
DEFAULT_CSS = """
#container {
# Remove the selected items' background
& .option-list--option-highlighted {
background: transparent;
text-style: none;
}
# Selected rows' text styling
& .list2 .option-list--option-selected {
color: ansi_bright_green;
}
& .list3 .option-list--option-selected {
color: ansi_bright_red;
}
}
"""
BINDINGS = [("q", "quit", "Quit")]
def __init__(self) -> None:
super().__init__()
def compose(self) -> ComposeResult:
with Container():
with Horizontal(id="container"):
yield SelectionList[str](*tuple(selection_items), id="list1")
yield MyStyledSelectionList1[str](*tuple(selection_items), id="list2", classes="list2")
yield MyStyledSelectionList2[str](*tuple(selection_items), id="list3", classes="list3")
if __name__ == "__main__":
app = MyApp()
app.run()
Screenshot (iTerm on a Mac):