pythontuitextual

Correct selector to target a 'Textual' SelectionList selected items' text?


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.


Solution

  • 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):

    enter image description here