style and components fixes

This commit is contained in:
Will McGugan
2025-02-10 20:02:43 +00:00
parent 1cfd33c76e
commit 661ffd13ce
11 changed files with 246 additions and 159 deletions

View File

@@ -1,6 +1,6 @@
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, OptionList from textual.widgets import Footer, Header, OptionList
from textual.widgets.option_list import Option, Separator from textual.widgets.option_list import Option
class OptionListApp(App[None]): class OptionListApp(App[None]):
@@ -11,22 +11,22 @@ class OptionListApp(App[None]):
yield OptionList( yield OptionList(
Option("Aerilon", id="aer"), Option("Aerilon", id="aer"),
Option("Aquaria", id="aqu"), Option("Aquaria", id="aqu"),
Separator(), None,
Option("Canceron", id="can"), Option("Canceron", id="can"),
Option("Caprica", id="cap", disabled=True), Option("Caprica", id="cap", disabled=True),
Separator(), None,
Option("Gemenon", id="gem"), Option("Gemenon", id="gem"),
Separator(), None,
Option("Leonis", id="leo"), Option("Leonis", id="leo"),
Option("Libran", id="lib"), Option("Libran", id="lib"),
Separator(), None,
Option("Picon", id="pic"), Option("Picon", id="pic"),
Separator(), None,
Option("Sagittaron", id="sag"), Option("Sagittaron", id="sag"),
Option("Scorpia", id="sco"), Option("Scorpia", id="sco"),
Separator(), None,
Option("Tauron", id="tau"), Option("Tauron", id="tau"),
Separator(), None,
Option("Virgon", id="vir"), Option("Virgon", id="vir"),
) )
yield Footer() yield Footer()

View File

@@ -35,7 +35,6 @@ from typing import (
import rich.repr import rich.repr
from rich.align import Align from rich.align import Align
from rich.style import Style
from rich.text import Text from rich.text import Text
from typing_extensions import Final, TypeAlias from typing_extensions import Final, TypeAlias
@@ -48,7 +47,7 @@ from textual.fuzzy import Matcher
from textual.message import Message from textual.message import Message
from textual.reactive import var from textual.reactive import var
from textual.screen import Screen, SystemModalScreen from textual.screen import Screen, SystemModalScreen
from textual.style import Style as VisualStyle from textual.style import Style
from textual.timer import Timer from textual.timer import Timer
from textual.types import IgnoreReturnCallbackType from textual.types import IgnoreReturnCallbackType
from textual.visual import VisualType from textual.visual import VisualType
@@ -190,6 +189,10 @@ class Provider(ABC):
Args: Args:
screen: A reference to the active screen. screen: A reference to the active screen.
""" """
if match_style is not None:
assert isinstance(
match_style, Style
), "match_style must be a Visual style if given"
self.__screen = screen self.__screen = screen
self.__match_style = match_style self.__match_style = match_style
self._init_task: Task | None = None self._init_task: Task | None = None
@@ -228,8 +231,13 @@ class Provider(ABC):
Returns: Returns:
A [fuzzy matcher][textual.fuzzy.Matcher] object for matching against candidate hits. A [fuzzy matcher][textual.fuzzy.Matcher] object for matching against candidate hits.
""" """
match_style = self.match_style
# match_style = Style(bold=True, underline=True)
return Matcher( return Matcher(
user_input, match_style=self.match_style, case_sensitive=case_sensitive user_input,
match_style=match_style,
case_sensitive=case_sensitive,
) )
def _post_init(self) -> None: def _post_init(self) -> None:
@@ -806,9 +814,7 @@ class CommandPalette(SystemModalScreen[None]):
self.app.post_message(CommandPalette.Opened()) self.app.post_message(CommandPalette.Opened())
self._calling_screen = self.app.screen_stack[-2] self._calling_screen = self.app.screen_stack[-2]
match_style = self.get_component_rich_style( match_style = self.get_visual_style("command-palette--highlight", partial=True)
"command-palette--highlight", partial=True
)
assert self._calling_screen is not None assert self._calling_screen is not None
self._providers = [ self._providers = [
@@ -1108,9 +1114,10 @@ class CommandPalette(SystemModalScreen[None]):
yield Content.from_rich_text(hit.prompt) yield Content.from_rich_text(hit.prompt)
else: else:
yield Content.from_markup(hit.prompt) yield Content.from_markup(hit.prompt)
# Optional help text # Optional help text
if hit.help: if hit.help:
help_style = VisualStyle.from_styles( help_style = Style.from_styles(
self.get_component_styles("command-palette--help-text") self.get_component_styles("command-palette--help-text")
) )
yield Content.from_markup(hit.help).stylize_before(help_style) yield Content.from_markup(hit.help).stylize_before(help_style)

View File

@@ -948,7 +948,7 @@ class Content(Visual):
self, self,
base_style: Style = Style.null(), base_style: Style = Style.null(),
end: str = "\n", end: str = "\n",
parse_style: Callable[[str], Style] | None = None, parse_style: Callable[[str | Style], Style] | None = None,
) -> Iterable[tuple[str, Style]]: ) -> Iterable[tuple[str, Style]]:
"""Render Content in to an iterable of strings and styles. """Render Content in to an iterable of strings and styles.
@@ -971,11 +971,13 @@ class Content(Visual):
yield end, base_style yield end, base_style
return return
get_style: Callable[[str], Style] get_style: Callable[[str | Style], Style]
if parse_style is None: if parse_style is None:
def get_style(style: str, /) -> Style: def get_style(style: str | Style) -> Style:
"""The default get_style method.""" """The default get_style method."""
if isinstance(style, Style):
return style
try: try:
visual_style = Style.parse(style) visual_style = Style.parse(style)
except Exception: except Exception:

View File

@@ -220,7 +220,7 @@ class Stylesheet:
self._parse_cache.clear() self._parse_cache.clear()
self._style_parse_cache.clear() self._style_parse_cache.clear()
def parse_style(self, style_text: str) -> Style: def parse_style(self, style_text: str | Style) -> Style:
"""Parse a (visual) Style. """Parse a (visual) Style.
Args: Args:
@@ -229,6 +229,8 @@ class Stylesheet:
Returns: Returns:
New Style instance. New Style instance.
""" """
if isinstance(style_text, Style):
return style_text
if style_text in self._style_parse_cache: if style_text in self._style_parse_cache:
return self._style_parse_cache[style_text] return self._style_parse_cache[style_text]
style = parse_style(style_text) style = parse_style(style_text)

View File

@@ -12,8 +12,9 @@ from re import IGNORECASE, escape, finditer, search
from typing import Iterable, NamedTuple from typing import Iterable, NamedTuple
import rich.repr import rich.repr
from rich.style import Style
from rich.text import Text from textual.content import Content
from textual.visual import Style
class _Search(NamedTuple): class _Search(NamedTuple):
@@ -203,7 +204,7 @@ class Matcher:
""" """
return self.fuzzy_search.match(self.query, candidate)[0] return self.fuzzy_search.match(self.query, candidate)[0]
def highlight(self, candidate: str) -> Text: def highlight(self, candidate: str) -> Content:
"""Highlight the candidate with the fuzzy match. """Highlight the candidate with the fuzzy match.
Args: Args:
@@ -212,11 +213,11 @@ class Matcher:
Returns: Returns:
A [rich.text.Text][`Text`] object with highlighted matches. A [rich.text.Text][`Text`] object with highlighted matches.
""" """
text = Text.from_markup(candidate) content = Content.from_markup(candidate)
score, offsets = self.fuzzy_search.match(self.query, candidate) score, offsets = self.fuzzy_search.match(self.query, candidate)
if not score: if not score:
return text return content
for offset in offsets: for offset in offsets:
if not candidate[offset].isspace(): if not candidate[offset].isspace():
text.stylize(self._match_style, offset, offset + 1) content = content.stylize(self._match_style, offset, offset + 1)
return text return content

View File

@@ -177,10 +177,14 @@ class Style:
new_style = Style( new_style = Style(
( (
other.background other.background
if self.background is None if (self.background is None or self.background.a == 0)
else self.background + other.background else self.background + other.background
), ),
self.foreground if other.foreground is None else other.foreground, (
self.foreground
if (other.foreground is None or other.foreground.a == 0)
else other.foreground
),
self.bold if other.bold is None else other.bold, self.bold if other.bold is None else other.bold,
self.dim if other.dim is None else other.dim, self.dim if other.dim is None else other.dim,
self.italic if other.italic is None else other.italic, self.italic if other.italic is None else other.italic,
@@ -368,6 +372,7 @@ class Style:
bold=self.bold, bold=self.bold,
dim=self.dim, dim=self.dim,
italic=self.italic, italic=self.italic,
underline=self.underline,
reverse=self.reverse, reverse=self.reverse,
strike=self.strike, strike=self.strike,
link=self.link, link=self.link,

View File

@@ -1044,17 +1044,20 @@ class Widget(DOMNode):
return partial_style if partial else style return partial_style if partial else style
def get_visual_style(self, *component_classes: str) -> VisualStyle: def get_visual_style(
self, *component_classes: str, partial: bool = False
) -> VisualStyle:
"""Get the visual style for the widget, including any component styles. """Get the visual style for the widget, including any component styles.
Args: Args:
component_classes: Optional component styles. component_classes: Optional component styles.
partial: Return a partial style (not combined with parent).
Returns: Returns:
A Visual style instance. A Visual style instance.
""" """
cache_key = (self._pseudo_classes_cache_key, component_classes) cache_key = (self._pseudo_classes_cache_key, component_classes, partial)
if (visual_style := self._visual_style_cache.get(cache_key, None)) is None: if (visual_style := self._visual_style_cache.get(cache_key, None)) is None:
background = Color(0, 0, 0, 0) background = Color(0, 0, 0, 0)
color = Color(255, 255, 255, 0) color = Color(255, 255, 255, 0)
@@ -1064,8 +1067,11 @@ class Widget(DOMNode):
def iter_styles() -> Iterable[StylesBase]: def iter_styles() -> Iterable[StylesBase]:
"""Iterate over the styles from the DOM and additional components styles.""" """Iterate over the styles from the DOM and additional components styles."""
for node in reversed(self.ancestors_with_self): if partial:
yield node.styles node = self
else:
for node in reversed(self.ancestors_with_self):
yield node.styles
for name in component_classes: for name in component_classes:
yield node.get_component_styles(name) yield node.get_component_styles(name)
@@ -4221,7 +4227,7 @@ class Widget(DOMNode):
""" """
self.app.capture_mouse(None) self.app.capture_mouse(None)
def select_all(self) -> None: def text_select_all(self) -> None:
"""Select the entire widget.""" """Select the entire widget."""
self.screen._select_all_in_widget(self) self.screen._select_all_in_widget(self)
@@ -4286,9 +4292,9 @@ class Widget(DOMNode):
async def _on_click(self, event: events.Click) -> None: async def _on_click(self, event: events.Click) -> None:
if event.widget is self: if event.widget is self:
if event.chain == 2: if event.chain == 2:
self.select_all() self.text_select_all()
elif event.chain == 3 and self.parent is not None: elif event.chain == 3 and self.parent is not None:
self.select_container.select_all() self.select_container.text_select_all()
await self.broker_event("click", event) await self.broker_event("click", event)

View File

@@ -11,7 +11,7 @@ from textual._loop import loop_last
from textual.binding import Binding, BindingType from textual.binding import Binding, BindingType
from textual.cache import LRUCache from textual.cache import LRUCache
from textual.css.styles import RulesMap from textual.css.styles import RulesMap
from textual.geometry import Region, Size from textual.geometry import Region, Size, clamp
from textual.message import Message from textual.message import Message
from textual.reactive import reactive from textual.reactive import reactive
from textual.scroll_view import ScrollView from textual.scroll_view import ScrollView
@@ -57,7 +57,7 @@ class Option:
self._prompt = prompt self._prompt = prompt
self._visual: Visual | None = None self._visual: Visual | None = None
self._id = id self._id = id
self._disabled = disabled self.disabled = disabled
self._divider = False self._divider = False
@property @property
@@ -70,9 +70,15 @@ class Option:
"""Optional ID for the option.""" """Optional ID for the option."""
return self._id return self._id
@property def _set_prompt(self, prompt: VisualType) -> None:
def disabled(self) -> bool: """Update the prompt.
return self._disabled
Args:
prompt: New prompt.
"""
self._prompt = prompt
self._visual = None
def __hash__(self) -> int: def __hash__(self) -> int:
return id(self) return id(self)
@@ -180,7 +186,7 @@ class OptionList(ScrollView, can_focus=True):
highlighted: reactive[int | None] = reactive(None) highlighted: reactive[int | None] = reactive(None)
"""The index of the currently-highlighted option, or `None` if no option is highlighted.""" """The index of the currently-highlighted option, or `None` if no option is highlighted."""
hover_option_index: reactive[int | None] = reactive(None) _mouse_hovering_over: reactive[int | None] = reactive(None)
"""The index of the option under the mouse or `None`.""" """The index of the option under the mouse or `None`."""
class OptionMessage(Message): class OptionMessage(Message):
@@ -247,14 +253,12 @@ class OptionList(ScrollView, can_focus=True):
tooltip: VisualType | None = None, tooltip: VisualType | None = None,
): ):
super().__init__(name=name, id=id, classes=classes, disabled=disabled) super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._option_id = 0
self._wrap = wrap self._wrap = wrap
self._markup = markup self._markup = markup
self._options: list[Option] = [] self._options: list[Option] = []
self._id_to_option: dict[str, Option] = {} self._id_to_option: dict[str, Option] = {}
self._option_to_index: dict[Option, int] = {} self._option_to_index: dict[Option, int] = {}
self._visuals: dict[int, Visual] = {}
self._option_render_cache: LRUCache[tuple[Option, Style], list[Strip]] self._option_render_cache: LRUCache[tuple[Option, Style], list[Strip]]
self._option_render_cache = LRUCache(maxsize=1024) self._option_render_cache = LRUCache(maxsize=1024)
@@ -264,6 +268,9 @@ class OptionList(ScrollView, can_focus=True):
self.tooltip = tooltip self.tooltip = tooltip
self.add_options(content) self.add_options(content)
if self._options:
# TODO: Inherited from previous version. Do we always want this?
self.action_first()
@property @property
def options(self) -> Sequence[Option]: def options(self) -> Sequence[Option]:
@@ -285,8 +292,11 @@ class OptionList(ScrollView, can_focus=True):
Returns: Returns:
The `OptionList` instance. The `OptionList` instance.
""" """
self._options.clear()
self._line_cache.clear() self._line_cache.clear()
self._option_render_cache.clear() self._option_render_cache.clear()
self._id_to_option.clear()
self._option_to_index.clear()
self.highlighted = None self.highlighted = None
self.refresh() self.refresh()
return self return self
@@ -298,6 +308,16 @@ class OptionList(ScrollView, can_focus=True):
new_options: Content of new options. new_options: Content of new options.
""" """
option_ids = [
option._id
for option in new_options
if isinstance(option, Option) and option._id is not None
]
if len(option_ids) != len(set(option_ids)):
raise DuplicateID(
"New options contain duplicated IDs; Ensure that the IDs are unique."
)
options = self._options options = self._options
add_option = self._options.append add_option = self._options.append
for prompt in new_options: for prompt in new_options:
@@ -309,17 +329,15 @@ class OptionList(ScrollView, can_focus=True):
continue continue
else: else:
option = Option(prompt) option = Option(prompt)
hash(option)
self._option_to_index[option] = len(options) self._option_to_index[option] = len(options)
add_option(option)
if option._id is not None: if option._id is not None:
if option._id in self._id_to_option: if option._id in self._id_to_option:
raise DuplicateID("Unable to add {option!r} due to duplicate ID") raise DuplicateID(f"Unable to add {option!r} due to duplicate ID")
self._id_to_option[option._id] = option self._id_to_option[option._id] = option
add_option(option)
return self return self
def add_option(self, option: Option | VisualType | None) -> Self: def add_option(self, option: Option | VisualType | None = None) -> Self:
"""Add a new option to the end of the option list. """Add a new option to the end of the option list.
Args: Args:
@@ -397,7 +415,7 @@ class OptionList(ScrollView, can_focus=True):
Returns: Returns:
The `OptionList` instance. The `OptionList` instance.
""" """
self._options[index]._disabled = disabled self._options[index].disabled = disabled
if index == self.highlighted: if index == self.highlighted:
self.highlighted = _widget_navigation.find_next_enabled( self.highlighted = _widget_navigation.find_next_enabled(
self._options, anchor=index, direction=1 self._options, anchor=index, direction=1
@@ -480,6 +498,8 @@ class OptionList(ScrollView, can_focus=True):
""" """
index = self._option_to_index[option] index = self._option_to_index[option]
self._mouse_hovering_over = None
self._pre_remove_option(option, index)
for option in self.options[index + 1 :]: for option in self.options[index + 1 :]:
current_index = self._option_to_index[option] current_index = self._option_to_index[option]
self._option_to_index[option] = current_index - 1 self._option_to_index[option] = current_index - 1
@@ -489,9 +509,19 @@ class OptionList(ScrollView, can_focus=True):
if option._id is not None: if option._id is not None:
del self._id_to_option[option._id] del self._id_to_option[option._id]
del self._option_to_index[option] del self._option_to_index[option]
self.highlighted = self.highlighted
self.refresh() self.refresh()
return self return self
def _pre_remove_option(self, option: Option, index: int) -> None:
"""Hook called prior to removing an option.
Args:
option: Option being removed.
index: Index of option being removed.
"""
def remove_option(self, option_id: str) -> Self: def remove_option(self, option_id: str) -> Self:
"""Remove the option with the given ID. """Remove the option with the given ID.
@@ -519,9 +549,59 @@ class OptionList(ScrollView, can_focus=True):
Raises: Raises:
OptionDoesNotExist: If there is no option with the given index. OptionDoesNotExist: If there is no option with the given index.
""" """
option = self._options[index] try:
option = self._options[index]
except IndexError:
raise OptionDoesNotExist(
f"Unable to remove; there is no option at index {index}"
) from None
return self._remove_option(option) return self._remove_option(option)
def _replace_option_prompt(self, index: int, prompt: VisualType) -> None:
"""Replace the prompt of an option in the list.
Args:
index: The index of the option to replace the prompt of.
prompt: The new prompt for the option.
Raises:
OptionDoesNotExist: If there is no option with the given index.
"""
self.get_option_at_index(index)._set_prompt(prompt)
self._clear_caches()
def replace_option_prompt(self, option_id: str, prompt: VisualType) -> Self:
"""Replace the prompt of the option with the given ID.
Args:
option_id: The ID of the option to replace the prompt of.
prompt: The new prompt for the option.
Returns:
The `OptionList` instance.
Raises:
OptionDoesNotExist: If no option has the given ID.
"""
self._replace_option_prompt(self.get_option_index(option_id), prompt)
return self
def replace_option_prompt_at_index(self, index: int, prompt: VisualType) -> Self:
"""Replace the prompt of the option at the given index.
Args:
index: The index of the option to replace the prompt of.
prompt: The new prompt for the option.
Returns:
The `OptionList` instance.
Raises:
OptionDoesNotExist: If there is no option with the given index.
"""
self._replace_option_prompt(index, prompt)
return self
@property @property
def _lines(self) -> Sequence[tuple[int, int]]: def _lines(self) -> Sequence[tuple[int, int]]:
"""A sequence of pairs of ints for each line, used internally. """A sequence of pairs of ints for each line, used internally.
@@ -549,6 +629,7 @@ class OptionList(ScrollView, can_focus=True):
def _clear_caches(self) -> None: def _clear_caches(self) -> None:
self._option_render_cache.clear() self._option_render_cache.clear()
self._line_cache.clear() self._line_cache.clear()
self.refresh()
def notify_style_update(self) -> None: def notify_style_update(self) -> None:
self._clear_caches() self._clear_caches()
@@ -557,6 +638,9 @@ class OptionList(ScrollView, can_focus=True):
self._clear_caches() self._clear_caches()
self.refresh() self.refresh()
def _on_mount(self) -> None:
self._update_lines()
def on_show(self) -> None: def on_show(self) -> None:
self.scroll_to_highlight() self.scroll_to_highlight()
@@ -567,11 +651,7 @@ class OptionList(ScrollView, can_focus=True):
event: The click event. event: The click event.
""" """
clicked_option: int | None = event.style.meta.get("option") clicked_option: int | None = event.style.meta.get("option")
if ( if clicked_option is not None and not self._options[clicked_option].disabled:
clicked_option is not None
and clicked_option >= 0
and not self._options[clicked_option].disabled
):
self.highlighted = clicked_option self.highlighted = clicked_option
self.action_select() self.action_select()
@@ -589,7 +669,7 @@ class OptionList(ScrollView, can_focus=True):
Args: Args:
event: The mouse movement event. event: The mouse movement event.
""" """
self.hover_option_index = event.style.meta.get("option") self._mouse_hovering_over = event.style.meta.get("option")
def _on_leave(self, _: events.Leave) -> None: def _on_leave(self, _: events.Leave) -> None:
"""React to the mouse leaving the widget.""" """React to the mouse leaving the widget."""
@@ -633,7 +713,7 @@ class OptionList(ScrollView, can_focus=True):
A list of strips. A list of strips.
""" """
padding = self.get_component_styles("option-list--option").padding padding = self.get_component_styles("option-list--option").padding
width = self.content_region.width - self._get_left_gutter_width() width = self.scrollable_content_region.width - self._get_left_gutter_width()
cache_key = (option, style) cache_key = (option, style)
if (strips := self._option_render_cache.get(cache_key)) is None: if (strips := self._option_render_cache.get(cache_key)) is None:
visual = self._get_visual(option) visual = self._get_visual(option)
@@ -657,14 +737,16 @@ class OptionList(ScrollView, can_focus=True):
def _update_lines(self) -> None: def _update_lines(self) -> None:
line_cache = self._line_cache line_cache = self._line_cache
lines = line_cache.lines lines = line_cache.lines
last_index = lines[-1][0] if lines else 0 if not self.options:
return
next_index = lines[-1][0] + 1 if lines else 0
get_visual = self._get_visual get_visual = self._get_visual
width = self.scrollable_content_region.width width = self.scrollable_content_region.width
if last_index < len(self.options) - 1: if next_index < len(self.options):
styles = self.get_component_styles("option-list--option") styles = self.get_component_styles("option-list--option")
for index, option in enumerate(self.options[next_index:], next_index):
for index, option in enumerate(self.options[last_index:], last_index):
line_cache.index_to_line[index] = len(line_cache.lines) line_cache.index_to_line[index] = len(line_cache.lines)
line_count = ( line_count = (
get_visual(option).get_height(styles, width) + option._divider get_visual(option).get_height(styles, width) + option._divider
@@ -716,6 +798,10 @@ class OptionList(ScrollView, can_focus=True):
strips = self._get_option_render(option, style) strips = self._get_option_render(option, style)
return strips[line_offset] return strips[line_offset]
def render_lines(self, crop: Region) -> list[Strip]:
self._update_lines()
return super().render_lines(crop)
def render_line(self, y: int) -> Strip: def render_line(self, y: int) -> Strip:
line_number = self.scroll_offset.y + y line_number = self.scroll_offset.y + y
try: try:
@@ -723,25 +809,20 @@ class OptionList(ScrollView, can_focus=True):
except IndexError: except IndexError:
return Strip.blank(self.scrollable_content_region.width) return Strip.blank(self.scrollable_content_region.width)
option = self.options[option_index] option = self.options[option_index]
mouse_over = self.hover_option_index == option_index mouse_over = self._mouse_hovering_over == option_index
component_classes: tuple[str, ...] = ("option-list--option",) component_class = ""
if option.disabled: if option.disabled:
component_classes = ( component_class = "option-list--option-disabled"
"option-list--option",
"option-list--option-disabled",
)
elif self.highlighted == option_index: elif self.highlighted == option_index:
component_classes = ( component_class = "option-list--option-highlighted"
"option-list--option",
"option-list--option-highlighted",
)
elif mouse_over: elif mouse_over:
component_classes = ( component_class = "option-list--option-hover"
"option-list--option",
"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")
style = self.get_visual_style(*component_classes)
strips = self._get_option_render(option, style) strips = self._get_option_render(option, style)
strip = strips[line_offset] strip = strips[line_offset]
return strip return strip
@@ -760,23 +841,28 @@ class OptionList(ScrollView, can_focus=True):
"""React to the highlighted option having changed.""" """React to the highlighted option having changed."""
if highlighted is None: if highlighted is None:
return return
if not self._options[highlighted].disabled: if not self._options[highlighted].disabled:
self.scroll_to_highlight() self.scroll_to_highlight()
self.post_message(
option: Option self.OptionHighlighted(self, self.options[highlighted], highlighted)
if highlighted is None: )
option = None
else:
option = self.options[highlighted]
self.post_message(self.OptionHighlighted(self, option, highlighted))
def scroll_to_highlight(self, top: bool = False) -> None: def scroll_to_highlight(self, top: bool = False) -> None:
"""Scroll to the highlighted option.
Args:
top: Ensure highlighted option is at the top of the widget.
"""
highlighted = self.highlighted highlighted = self.highlighted
if highlighted is None or not self.is_mounted: if highlighted is None or not self.is_mounted:
return return
y = self._index_to_line[highlighted] self._update_lines()
try:
y = self._index_to_line[highlighted]
except KeyError:
return
option = self.options[highlighted] option = self.options[highlighted]
height = self._heights[highlighted] - option._divider height = self._heights[highlighted] - option._divider
@@ -820,33 +906,36 @@ class OptionList(ScrollView, can_focus=True):
Args: Args:
direction: `-1` to move up a page, `1` to move down a page. direction: `-1` to move up a page, `1` to move down a page.
""" """
if not self._options:
return
height = self.content_region.height height = self.scrollable_content_region.height
y = clamp(
option_index = self.highlighted or 0 self._index_to_line[self.highlighted or 0] + direction * height,
0,
y = min(
self._index_to_line[option_index] + direction * height,
len(self._lines) - 1, len(self._lines) - 1,
) )
option_index = self._lines[y][0] option_index = self._lines[y][0]
self.highlighted = _widget_navigation.find_next_enabled_no_wrap(
target_option = _widget_navigation.find_next_enabled_no_wrap(
candidates=self._options, candidates=self._options,
anchor=option_index, anchor=option_index,
direction=direction, direction=direction,
with_anchor=True, with_anchor=True,
) )
if target_option is not None:
self.highlighted = target_option
def action_page_up(self): def action_page_up(self):
"""Move the highlight up one page.""" """Move the highlight up one page."""
self._move_page(-1) if self.highlighted is None:
self.action_first()
else:
self._move_page(-1)
def action_page_down(self): def action_page_down(self):
"""Move the highlight down one page.""" """Move the highlight down one page."""
self._move_page(1) if self.highlighted is None:
self.action_last()
else:
self._move_page(1)
def action_select(self) -> None: def action_select(self) -> None:
"""Select the currently highlighted option. """Select the currently highlighted option.
@@ -854,34 +943,9 @@ class OptionList(ScrollView, can_focus=True):
If an option is selected then a If an option is selected then a
[OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] will be posted. [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected] will be posted.
""" """
if self.highlighted is None:
return
highlighted = self.highlighted highlighted = self.highlighted
if highlighted is None:
return
option = self._options[highlighted] option = self._options[highlighted]
if highlighted is not None and not option.disabled: if highlighted is not None and not option.disabled:
self.post_message(self.OptionSelected(self, option, highlighted)) self.post_message(self.OptionSelected(self, option, highlighted))
if __name__ == "__main__":
from textual.app import App, ComposeResult
TEXT = """I must not fear.
Fear is the [u]mind-killer[/u].
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.
And when it has gone past, I will turn the inner eye to see its path.
Where the fear has gone there will be nothing. Only I will remain."""
class OLApp(App):
def compose(self) -> ComposeResult:
yield OptionList(
*(
["Hello", "World!", None, TEXT, None, "Foo", "Bar", "Baz", None]
* 100
)
)
app = OLApp()
app.run()

View File

@@ -15,7 +15,12 @@ from textual import events
from textual.binding import Binding from textual.binding import Binding
from textual.messages import Message from textual.messages import Message
from textual.strip import Strip from textual.strip import Strip
from textual.widgets._option_list import Option, OptionList, OptionListContent from textual.widgets._option_list import (
Option,
OptionDoesNotExist,
OptionList,
OptionListContent,
)
from textual.widgets._toggle_button import ToggleButton from textual.widgets._toggle_button import ToggleButton
SelectionType = TypeVar("SelectionType") SelectionType = TypeVar("SelectionType")
@@ -229,6 +234,10 @@ class SelectionList(Generic[SelectionType], OptionList):
self._send_messages = False self._send_messages = False
"""Keep track of when we're ready to start sending messages.""" """Keep track of when we're ready to start sending messages."""
options = [self._make_selection(selection) for selection in selections] options = [self._make_selection(selection) for selection in selections]
self._values: dict[SelectionType, int] = {
option.value: index for index, option in enumerate(options)
}
"""Keeps track of which value relates to which option."""
super().__init__( super().__init__(
*options, *options,
name=name, name=name,
@@ -237,10 +246,6 @@ class SelectionList(Generic[SelectionType], OptionList):
disabled=disabled, disabled=disabled,
wrap=False, wrap=False,
) )
self._values: dict[SelectionType, int] = {
option.value: index for index, option in enumerate(options)
}
"""Keeps track of which value relates to which option."""
@property @property
def selected(self) -> list[SelectionType]: def selected(self) -> list[SelectionType]:
@@ -510,20 +515,25 @@ class SelectionList(Generic[SelectionType], OptionList):
A [`Strip`][textual.strip.Strip] that is the line to render. A [`Strip`][textual.strip.Strip] that is the line to render.
""" """
# First off, get the underlying prompt from OptionList. # TODO: This is rather crufty and hard to fathom. Candidate for a rewrite.
prompt = super().render_line(y)
# If it looks like the prompt itself is actually an empty line... # First off, get the underlying prompt from OptionList.
if not prompt: line = super().render_line(y)
# ...get out with that. We don't need to do any more here.
return prompt # # If it looks like the prompt itself is actually an empty line...
# if not prompt:
# # ...get out with that. We don't need to do any more here.
# return prompt
# We know the prompt we're going to display, what we're going to do # We know the prompt we're going to display, what we're going to do
# is place a CheckBox-a-like button next to it. So to start with # is place a CheckBox-a-like button next to it. So to start with
# let's pull out the actual Selection we're looking at right now. # let's pull out the actual Selection we're looking at right now.
_, scroll_y = self.scroll_offset _, scroll_y = self.scroll_offset
selection_index = scroll_y + y selection_index = scroll_y + y
selection = self.get_option_at_index(selection_index) try:
selection = self.get_option_at_index(selection_index)
except OptionDoesNotExist:
return line
# Figure out which component style is relevant for a checkbox on # Figure out which component style is relevant for a checkbox on
# this particular line. # this particular line.
@@ -533,10 +543,12 @@ class SelectionList(Generic[SelectionType], OptionList):
if self.highlighted == selection_index: if self.highlighted == selection_index:
component_style += "-highlighted" component_style += "-highlighted"
# Get the underlying style used for the prompt. # # # Get the underlying style used for the prompt.
underlying_style = next(iter(prompt)).style underlying_style = next(iter(line)).style or self.rich_style
assert underlying_style is not None assert underlying_style is not None
# underlying_style = self.rich_style
# Get the style for the button. # Get the style for the button.
button_style = self.get_component_rich_style(component_style) button_style = self.get_component_rich_style(component_style)
@@ -565,7 +577,7 @@ class SelectionList(Generic[SelectionType], OptionList):
Segment(ToggleButton.BUTTON_INNER, style=button_style), Segment(ToggleButton.BUTTON_INNER, style=button_style),
Segment(ToggleButton.BUTTON_RIGHT, style=side_style), Segment(ToggleButton.BUTTON_RIGHT, style=side_style),
Segment(" ", style=underlying_style), Segment(" ", style=underlying_style),
*prompt, *line,
] ]
) )
@@ -617,24 +629,17 @@ class SelectionList(Generic[SelectionType], OptionList):
""" """
return cast("Selection[SelectionType]", super().get_option(option_id)) return cast("Selection[SelectionType]", super().get_option(option_id))
def _remove_option(self, index: int) -> None: def _pre_remove_option(self, option: Option, index: int) -> None:
"""Remove a selection option from the selection option list. """Hook called prior to removing an option."""
assert isinstance(option, Selection)
Args:
index: The index of the selection option to remove.
Raises:
IndexError: If there is no selection option of the given index.
"""
option = self.get_option_at_index(index)
self._deselect(option.value) self._deselect(option.value)
del self._values[option.value] del self._values[option.value]
# Decrement index of options after the one we just removed. # Decrement index of options after the one we just removed.
self._values = { self._values = {
option_value: option_index - 1 if option_index > index else option_index option_value: option_index - 1 if option_index > index else option_index
for option_value, option_index in self._values.items() for option_value, option_index in self._values.items()
} }
return super()._remove_option(index)
def add_options( def add_options(
self, self,

View File

@@ -6,12 +6,7 @@ import pytest
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import OptionList from textual.widgets import OptionList
from textual.widgets.option_list import ( from textual.widgets.option_list import DuplicateID, Option, OptionDoesNotExist
DuplicateID,
Option,
OptionDoesNotExist,
Separator,
)
class OptionListApp(App[None]): class OptionListApp(App[None]):
@@ -21,7 +16,7 @@ class OptionListApp(App[None]):
yield OptionList( yield OptionList(
"0", "0",
Option("1"), Option("1"),
Separator(), None,
Option("2", disabled=True), Option("2", disabled=True),
None, None,
Option("3", id="3"), Option("3", id="3"),

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from textual import on from textual import on
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.widgets import OptionList from textual.widgets import OptionList
from textual.widgets.option_list import Option, Separator from textual.widgets.option_list import Option
class OptionListApp(App[None]): class OptionListApp(App[None]):
@@ -12,7 +12,7 @@ class OptionListApp(App[None]):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield OptionList( yield OptionList(
Option("0"), Option("0"),
Separator(), None,
Option("1"), Option("1"),
) )